iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
自我挑戰組

SQL Server 基礎&調教系列 第 23

【效能調教】 23.用執行計畫分析查詢行為

  • 分享至 

  • xImage
  •  

執行計畫是理解查詢最佳化所做選擇的最佳窗口,JOIN 操作類型、使用到的索引、這些索引實際上如何被用,都會寫在這裡。

但有一點必須非常明確的說 : 執行計畫絕對不是效能本身的衡量指標。

預估 vs 實際執行計畫

SSMS 會顯示兩種不同類型的計畫 : 預估執行計畫與實際執行計畫

問題在於,這兩個名稱其實並不精確。嚴格來說,執行計畫只有一種。

實際執行計畫只是在原本的那份執行計畫上面,再加上查詢真的跑完之後收集到的數據。

我們會從各種來源取得執行計畫 :
DMVs
擴充事件
Query Store

就是我前面提到的三個我最常用的擷取方式,但不論是哪一種擷取方式,只要沒有發生重新編譯,擷取的計畫都會是相同的。

實際執行計畫額外加入的資訊非常有價值,通常都是 :

  • 等待統計資訊
  • 查詢執行時間
  • 實際資料列數
  • 運算子的實際執行次數

大說數時候,應該要盡量擷取包含這些資訊的實際執行計畫,這些額外資訊非常有幫助。

但是有例外,因為有些正式環境不一定會讓你真的去執行,所以你會拿不到實際執行計畫,這時候也不用排斥使用不包含執行階段指標的執行計劃來分析。

另外用 Query Store 取回計畫或是查詢計畫快取的時候,取得的計畫不會包含執行階段指標。

擷取執行計劃 ( SSMS )

擷取有很多種方法,最簡單的方法就是直接用 SSMS 去取得。

但我們也可以用 DMV,直接從記憶體中的計劃快取取出查詢計劃。

如果有啟用 Query Store,那裏也會保存執行計劃。

擴充事件也可以擷取各種類型的執行計劃。

有幾個注意事項要先講 :

  1. 擷取包含執行階段指標的執行計劃,這件事是一個成本較高的操作。原因有兩個,第一,必須實際查詢;第二,資料庫引擎需要在較細的層級擷取相關指標。
  2. 上述的過程會直接負面影響查詢的執行時間跟資源使用量。因此我建議養成習慣 : 要碼擷取執行計劃,要碼擷取執行階段指標,不要兩個同時做。
    https://ithelp.ithome.com.tw/upload/images/20260823/201185810qHwkiKdqO.jpg
    上圖三個框框就是 SSMS 提供的執行計畫查詢方式
    左邊那個會只列出執行計畫
    中間那個會在真的查詢的時候附帶執行計畫
    右邊那個會外加統計資訊

隨便跑個查詢可以去看看執行計畫,至於執行計畫那裏面是什麼東西等等再說

SELECT soh.SalesOrderNumber,
       p.Name,
       sod.OrderQty
FROM Sales.SalesOrderHeader AS soh
     JOIN Sales.SalesOrderDetail AS sod
       ON sod.SalesOrderID = soh.SalesOrderID
     JOIN Production.Product AS p
       ON p.ProductID = sod.ProductID
WHERE soh.CustomerID = 30052;

https://ithelp.ithome.com.tw/upload/images/20260823/20118581L5tjWNFoOX.png

DMVs

DECLARE @sql nvarchar(max) = N'SELECT soh.SalesOrderNumber,
p.Name,
sod.OrderQty
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON sod.SalesOrderID = soh.SalesOrderID
JOIN Production.Product AS p
ON p.ProductID = sod.ProductID
WHERE soh.CustomerID = 30052;';

EXEC sys.sp_executesql @sql;

SELECT dest.text AS [查詢文字],
       deqp.query_plan AS [執行計畫],
       deqs.execution_count AS [執行次數],
       deqs.total_elapsed_time AS [累計執行時間],
       deqs.last_elapsed_time AS [上次執行時間]
FROM sys.dm_exec_query_stats AS deqs
CROSS APPLY sys.dm_exec_query_plan(deqs.plan_handle) AS deqp
CROSS APPLY sys.dm_exec_sql_text(deqs.sql_handle) AS dest
WHERE dest.text = @sql;

我用這種方式,是要確保只會擷取到這個查詢,查得快還省效能。

這時候執行計畫是個 XML 然後他只是執行計劃本身,不會有執行階段指標。

然後你可以點他,會帶到執行計畫的畫面,但是因為現在是簡單的查詢,XML 會有巢狀層級限制,某些非常大且複雜的查詢就會超過這個限制跑不出來。

然後還有一個方法可以用 DMV 來取得快取鍾某個查詢的最後一次實際執行計畫也就是 sys.dm_exec_query_plan_stats。
不過要用這個 DMV 的話要先啟用輕量級統計分析

ALTER DATABASE SCOPED CONFIGURATION SET
LAST_QUERY_PLAN_STATS = ON;

然後就一樣得用法

SELECT dest.text,
       deqps.query_plan,
       deqs.execution_count,
       deqs.total_elapsed_time,
       deqs.last_elapsed_time
FROM sys.dm_exec_query_stats AS deqs
     CROSS APPLY
     sys.dm_exec_query_plan_stats(deqs.plan_handle) AS deqps
     CROSS APPLY
     sys.dm_exec_sql_text(deqs.sql_handle) AS dest
WHERE dest.text LIKE 'SELECT soh.SalesOrderNumber,
       p.Name,%';

但是這個用法不一定可以拿到實際的執行計畫,要有實際的執行計畫的先決條件是,這個 PLAN 還在 PLAN CACHE 裡。

Query Store

這個主題比較大會再開一篇來寫

但這邊可以先快速看一下怎麼從 query store 取回執行計畫

SQL Server 2025 預設這個是啟用的,其他版本要去查一下,要用這個功能就一定要啟用

-- 啟用
ALTER DATABASE CURRENT SET QUERY_STORE = ON;

ALTER DATABASE CURRENT SET QUERY_STORE (
    OPERATION_MODE = READ_WRITE,
    QUERY_CAPTURE_MODE = ALL
);
SELECT qsq.query_id AS [查詢識別碼],
       qsq.query_hash AS [查詢雜湊值],
       CAST(qsp.query_plan AS XML) AS [執行計畫],
       qsqt.query_sql_text AS [查詢文字]
FROM sys.query_store_query AS qsq
     JOIN sys.query_store_plan AS qsp
       ON qsp.query_id = qsq.query_id
     JOIN sys.query_store_query_text AS qsqt
       ON qsqt.query_text_id = qsq.query_text_id
WHERE qsqt.query_sql_text LIKE 'SELECT soh.SalesOrderNumber%';

擴充事件

另一種擷取執行計畫的方式,是使用 Extended Events。可以使用幾種不同的事件來擷取執行計畫:

  • query_post_compilation_showplan:在指定查詢完成編譯程序後發生。
  • query_pre_execution_showplan:在查詢完成最佳化程序後發生。最佳化程序不同於編譯程序,而且在執行流程中發生於編譯之後。
  • query_post_execution_plan_profile:SQL Server 2017 以上版本可用。它使用輕量級查詢分析程序,擷取執行計畫與執行階段指標。
  • query_post_execution_showplan:在查詢執行完成後發生,因此可以同時擷取執行計畫與執行階段指標。

用擴充事件去擷取執行計畫非常消耗資源,如果真的要用這個方法,要審慎使用並仔細篩選事件。

最常見的用途,通常是擷取編譯完成後的執行計畫,或者使用其中一種「執行後」方法,同時擷取執行計畫與執行階段指標。

如果可以的話,當要擷取執行階段指標時,建議使用輕量級查詢分析程序,以降低擷取執行計畫所帶來的額外負擔。

執行計畫物件

接下來要開始理解執行計畫裡面到底是什麼

執行計畫裡面的圖案,這個之後我都統稱運算子。
https://ithelp.ithome.com.tw/upload/images/20260823/20118581rnZF7aQqMJ.png
每個運算子下方會顯示該運算子的邏輯名稱,以及這個操作所代表的實體動作

接著,在每個運算子下方,還會顯示該操作的預估成本。這個成本是由查詢最佳化工具內部計算出來的,用來以抽象方式表示為了滿足查詢需求,預估需要使用多少 CPU、記憶體與 I/O 資源。

這個成本永遠都是預估值,絕對不是任何形式的實際量測值。即使是在包含執行階段指標的執行計畫中,成本也仍然是預估值。
https://ithelp.ithome.com.tw/upload/images/20260823/20118581ESpEIAKtjs.png
https://ithelp.ithome.com.tw/upload/images/20260823/20118581B31KKmq4C3.png
像上面這個圖,20是估計資料列數、22是實際資料列數、0.021s 是花費時間

然後下面這張旁邊會有這種像管線的東西,帶有箭頭,這代表資料流動方向
https://ithelp.ithome.com.tw/upload/images/20260823/2011858110nJRO1ez2.png
他還有粗細之分,粗的代表資料流動大、細的就代表小。

滑鼠移動到管線或是運算子上面都可以看到額外詳細資訊。
https://ithelp.ithome.com.tw/upload/images/20260823/20118581ZU5XnW72M5.pnghttps://ithelp.ithome.com.tw/upload/images/20260823/20118581c8FibmUg42.png
右鍵運算子選屬性可以看到更多資訊
https://ithelp.ithome.com.tw/upload/images/20260823/20118581THEiAgWZl6.png
這很有用等等會說

閱讀執行計畫

第一種閱讀方式

從邏輯上來看,執行計畫的閱讀方式就像英文書一樣,從左到右。也就是說,計畫中的第一個運算子,是左上角的 SELECT 運算子。接著是第一個 Nested Loops 運算子,再來是第二個 Nested Loops 運算子,依此類推沿著整條線往右看。

第一個運算子其實不是 SELECT,嚴格來說那只是中繼資料

但創造另一個名詞會變得更難理解,所以我還是叫他運算子

至於真正第一運算子,有 NodeID ( 節點識別碼 ) 的,在這裡是最左邊的第一個巢狀迴圈

https://ithelp.ithome.com.tw/upload/images/20260823/20118581VPIn1XOaUd.png

這種邏輯上的閱讀方式,反映的是查詢引擎內部初始化執行計畫的方式。每個運算子會依序向它後方的運算子要求資料,直到找到資料,並透過其他運算子一路回傳。

第二種閱讀方式

第二種是順著資料流來看,也就是跟第一種反著看,從最右邊、最上方的位置開始

在我們一直使用的範例中,這個起點是針對IX_SalesOrderHeader_CustomerID 索引的 Index Seek 運算子。

https://ithelp.ithome.com.tw/upload/images/20260823/20118581hEY4W1ICP1.png
接著,資料會透過各個運算子往左流動。資料在運算子之間流動的方式,取決於處理模式。處理模式有兩種:row mode 與 batch mode。

row mode 會一次移動單一資料列
batch mode 一次移動一批資料

最後看懂運算子

運算子的描述是不錯的一個理解運算子的起點
https://ithelp.ithome.com.tw/upload/images/20260823/20118581p7caHfs9N9.png
阿但是中文翻譯的通常都很爛,所以最好去查查或是問 AI。

例如說這個巢狀迴圈,他其實是 JOIN 運算子的一種,JOIN 還有其他三種;而但凡是 JOIN 的操作,就需要兩組資料輸入,所以他寫那個什麼每個頂端(外部)輸入資料,然後底部又輸入資料的意思是 :
上面那一路資料每來一筆,SQL Server 就拿這一筆去下面那一路資料找符合條件的資料,找到就輸出。
https://ithelp.ithome.com.tw/upload/images/20260823/20118581Ys8GGsC81w.jpg
而且他還寫掃描,這很容易讓人誤會,那個不是執行計劃裏面的 SCAN,他是去下面那一列找資料的意思。

用中文的話就是這樣沒辦法,但現在有 AI 了,結圖問 AI 就好了。

執行計畫應該要看什麼

從前面到這裡,已經知道怎麼擷取執行計畫、執行計劃裏面有什麼物件、執行計劃該怎麼閱讀

然後到這一步,你會發現執行計劃裏面有太多的運算子、太多的屬性,不可能全部看完。

當然現在有 AI 的話這是有機會的,但一樣再次強調,如果有機敏資料,不建議整包丟給 AI。

所以基於這些原因,我們通常都會從一些指標或線索開始看,我一般來說看執行計劃的時候會先看下列幾個項目 :

  • 第一個運算子 : 就是最左最上面那個,因為他會包含整份計畫的中繼資料
  • 警告 : 如果有的話會有一個驚嘆號
  • 成本最高的操作
  • 粗管線
  • 不認識的運算子
  • Scan 操作 : seek 跟 scan 不是一定誰好誰壞;但是 scan 代表 I/O,而 I/O 常常是問題的來源
  • 預估跟實際的差異 : 如果差距很大,就很值得關注。

第一個運算子

就是最左上角那個,他會包含關於執行計劃本身的中繼資料,但並不是所有計畫都有這個運算子。

至於為什麼第一步看這個呢,因為再他的屬性裡面可以看到很多東西
https://ithelp.ithome.com.tw/upload/images/20260823/20118581dJxohT2AX9.png
首先,快取的計劃大小,會顯示這份執行計畫在記憶體中占用的大小

QueryHash 可以視為某個查詢的指紋,可以用來搜尋相似的查詢。

QueryPlanHash 也是相同的概念。

MemoryGrantInfo 可以用來了解 SQL Server 認為這份計劃需要配置多少記憶體。

最後最下面有警告資訊

以上這些資訊都描述執行計劃如何被編譯、編譯時的設定,以及其他相關背景。

因此我第一個都從這裡開始看。

警告

這是用來指出某些可能影響查詢效能的淺再問題。

他不一定自動代表真的有問題,是表示可能存在問題

我們一直以來用的那個範例警告,我在前面有說過那是什麼,簡而言之是 SQL Server 覺得轉換 convert 會對估計有影響,但其實在我們的查詢裡面是沒有影響的,所以那是個虛假的警告。

另外還有一種警告符號

--執行
SELECT pv.OnOrderQty,
       a.City
FROM Purchasing.ProductVendor AS pv,
     Person.Address AS a
WHERE a.City = 'Tulsa';

https://ithelp.ithome.com.tw/upload/images/20260823/201185814srVSBlmbh.png
所以警告有兩種圖案 紅色叉叉 跟 黃色驚嘆號

但這沒什麼關係因為只要有警告,就去看他為什麼警告就好,不是說紅色黃色哪一個比較嚴重。

阿在這裡的紅色叉叉是因為我在寫 join 的時候故意不寫 join 條件,所以他給一個警告。

成本最高的操作

雖然這是估計,但這還是最佳化工具用來做決策的依據
https://ithelp.ithome.com.tw/upload/images/20260823/20118581Xb1hVmgLTM.png
在上一個例子中可以看到這一個運算子的成本是 91%,是整份執行計劃裏面成本最高的。

如果真的要調整,最可能優先關注的就是這個地方。

但是並不是說問題一定出在這裡,因為這都是估計。

粗管線

由於管線代表資料移動,因此辨識出大量資料移動的位置,可以幫助我們解讀執行計畫,進而找出最可能造成效能問題的原因。

在查看管線時,另一個需要注意的重點,是資料流動的變化型態:它是從粗管線變成細管線,還是相反,從細管線逐漸變粗

管線變粗或變細,代表資料量在某個階段發生明顯變化。這個變化點值得去檢查,但不代表一定要改 SQL。

第一種情況,也就是粗管線變細,代表資料在較後面的階段才被過濾掉。這表示 SQL Server 前面可能先讀取或處理了大量資料,之後才把不需要的資料排除。這種情況下,索引可能會有幫助,或者也可能需要調整查詢寫法。

第二種情況,則是隨著查詢執行推進,資料量逐漸被放大。資料移動量增加,通常也代表更多 I/O 與更多記憶體資源被使用。這同樣可能是需要調整查詢寫法的位置。

這看起來很像廢話,所以我要舉實際案例來說

情況 1 由粗變細 :
這代表前面處理很多資料,到後面才過濾掉
也就是說有很多資料其實一開始根本不用處理或是 SQL Server 太晚才把不需要的資料丟掉。

例如寫
WHERE YEAR( OrderDate ) = 2026
這超爛,會讓大量資料先被掃出來,然後丟要 year(),才過濾

改成
WHERE OrderDate >= '20260101'
AND OrderDate < '20270101'
這樣就會讓過濾提早發生,避免效能浪費

情況 2 由細變粗 :
這通常發生在 join,像是

Customer 1 筆
JOIN Order 100 筆
JOIN OrderDetail 1000 筆

這不一定有錯,因為資料本來就需要這些名細

但是要確保這個東西的確是你需要、必要的然後要記得

  • 不必要的表不要 Join
  • 先過濾再 JOIN
  • 先GROUP BY 再 JOIN

原則概念就是,JOIN TABLE 的資料筆數,再可以符合想要的結果的時候,資料數量越少越好。

不認識的運算子

如果你不知道某個運算子是什麼,那它就應該引起你的注意,因為理解它有助於你更清楚知道查詢是如何被處理的。

不理解為什麼某運算子會在這個情況下使用也應該要引起注意。
https://ithelp.ithome.com.tw/upload/images/20260823/201185818764Fth5dl.png
假設這兩個你不知道是什麼,滑鼠移動上去看可以看描述,還是不懂就 google 或問 AI。

這個計算純量是 SQL Server 再這個步驟中,根據資料列裡現有的值,計算出一個新的值。

因為我們的 範例語法是這樣

SELECT soh.SalesOrderNumber,
       p.Name,
       sod.OrderQty
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
  ON sod.SalesOrderID = soh.SalesOrderID
JOIN Production.Product AS p
  ON p.ProductID = sod.ProductID
WHERE soh.CustomerID = 30052;

但在 AdventureWorks2022 裡,SalesOrderNumber 不是普通欄位,而是計算欄位

所以 SQL Server 必須用這個計算純量的步驟來產生這個值。

把這個計算純量屬性打開來可以看到這個

Scalar Operator(
    isnull(
        N'SO' + CONVERT(nvarchar(23),
        [AdventureWorks2022].[Sales].[SalesOrderHeader].[SalesOrderID]
        as [soh].[SalesOrderID], 0),
        N'*** ERROR ***'
    )
)

這也是前面警告來源的原因

順帶一題由此可知,再建立 table 的時候,我不是很喜歡把運算這種東西當成一個 table 欄位,因為之後你每次用這 table 都會跑一次這個。

SCAN

這是一個重要線索,原因是 : 他代表資料移動。

資料移動越多,就表示需要更多磁碟與記憶體存取,而這經常是造成效能問題的原因。因此,在執行計畫中尋找 Scan,是一種可以更快速理解問題可能位置的方法。

SELECT *
FROM Production.UnitMeasure AS um;

但是如果足夠理解什麼是索引,你很自然的就知道,上面這種查詢語法,唯一能滿足他的方式就是只有 SCAN,所以這時候你不能說什麼效能問題+索引,你要去改語法,除非你真的迫切需要 * 號。

預估與實際差異

https://ithelp.ithome.com.tw/upload/images/20260823/20118581C0h5yzfNjK.png

再看一次這個運算子,再說一次預估資料是 20、實際資料是22、預估值是 110%

這個案例中,這算已經足夠接近,不需要特別擔心。

實際上通常要看到 300% 以上或反過來只有 20%以下才值得注意。

但從圖上看只會看到資料數量的預估差異,還有很多可以去屬性裡面看

https://ithelp.ithome.com.tw/upload/images/20260823/20118581R0wBLErLVE.png

例如說這個,估計重新繫結,估計是19.3468,而下面實際是0。

這種差異可能是各種效能問題的重要指標,因此我會把這類比較當作線索之一。

那什麼是重新繫結?

痾簡單說是 : 外層資料每換一個新的參數值,內層運算子就必須重新初始化、重新找資料,這個動作就叫 Rebind。
很複雜的說是巢狀迴圈、Spool、內部輸入重新執行有關的東西,這之後會寫。

以上這些線索看完之後,可以快速的找到執行計畫中可能存在的問題。

但是這些線索只能幫助我們走到某個程度。

簡單的線索判讀之後,剩下困難的部分,就要真正去理解執行計畫中正在發生什麼。

  1. 首先,要養成追蹤個個運算子的 NodeID 習慣。了解 NodeID 可以讓我們知道運算子被初始化的順序,進而幫助我們理解查詢是如何被處理的。
    也會遇到某些運算子會回頭參照其他運算子的情況。因此,留意其他 NodeID 值的參照,有助於理解整份執行計畫。
  2. 然後,要養成追蹤每個運算子輸出內容的習慣。許多運算子會改變結果集中的欄位,或是新增欄位。知道這些欄位是從哪裡來的,有助於理解執行計畫。
  3. 最後,應該盡早養成使用運算子屬性的習慣。

上一篇
【效能調教】 22.擷取查詢效能指標的方法
下一篇
【效能調教】 24.統計資料 & Cardinality
系列文
SQL Server 基礎&調教30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言